또는 fgets(buf, sizeof(buf), stdin)을 쓰고 개행을 제거한다. 입력이 버퍼보다 길면 남은 글자가 입력 버퍼에 남는다는 점도 기억해 둔다.
5. 직접 구현 — my_strncpy / my_strncat
가장 흔한 실수: 루프 안의 strlen(dst)
요약
문제: strcat 직접 구현 결과가 "dsts"로 끝남
원인: 루프 안의 strlen(dst)가 첫 글자를 쓰는 순간 3→4로 변함
처방: 길이를 루프 전에 한 번만 구해 변수에 저장
결과: dst[3], dst[5], dst[6]… 한 칸씩 건너뛰며 써짐
상세 보기
처음 짠 strcat 구현은 이랬다.
for (i = 0; i < strlen(src) + 1; i++) { dst[i + strlen(dst)] = src[i]; // dst가 바뀌면 strlen(dst)도 바뀐다!}
dst="dst", src="src"일 때:
i
strlen(dst)
쓰는 위치
결과
0
3
dst[3]='s'
"dsts"
1
4
dst[5]='r'
dst[4]는 \0이라 여전히 "dsts"
2
4
dst[6]='c'
여전히 "dsts"
3
4
dst[7]='\0'
여전히 "dsts"
첫 글자를 쓰는 순간 strlen(dst)가 3에서 4로 변해 다음 글자가 한 칸씩 건너뛴 위치에 써진다. 원인은 간단하다 — 길이를 루프 전에 한 번만 구해 변수에 저장하면 된다.
고친 구현
요약
문제: my_strncat/my_strncpy의 크기 검사·널 문자 처리 오류
원인: 널 문자 자리(+1) 누락, 검사 후 쓰기 순서, strlen을 루프 조건에 사용
처방: dstsize < dlen + slen + 1 검사, i <= slen으로 \0까지 복사, 검사 통과 후 memset
결과: gdb의 display strlen(dst)로 변하는 순간 확인 가능
상세 보기
int my_strncat(char *dst, size_t dstsize, const char *src) { size_t dlen = strlen(dst); size_t slen = strlen(src); /* 널 문자까지 들어가야 하므로 dlen + slen + 1 <= dstsize */ if (dstsize < dlen + slen + 1) { printf("Error overflow\n"); return -1; } for (size_t i = 0; i <= slen; i++) { /* '\0'까지 복사 */ dst[dlen + i] = src[i]; } return 1;}int my_strncpy(char *dst, size_t dstsize, const char *src) { size_t slen = strlen(src); if (dstsize < slen + 1) { /* slen이 아니라 slen + 1 */ printf("Error overflow\n"); return -1; } memset(dst, 0, dstsize); for (size_t i = 0; i <= slen; i++) { dst[i] = src[i]; } return 1;}
고친 점을 정리하면 다음과 같다.
크기 검사에 + 1: 널 문자 자리를 빼먹으면 딱 맞는 길이에서 1바이트가 넘친다. strlen(dst) + strlen(src) - 1처럼 size_t 끼리 빼면 언더플로 위험도 있다.
i <= slen으로 널 문자까지 복사: 별도의 '\0' 처리 분기가 필요 없다.
strlen을 루프 조건에 쓰지 않기: 매번 호출하면 O(n²)이고 위 버그의 원인이 된다.
검사 후에 memset: 실패할 거라면 dst를 건드리지 않는 편이 안전하다.
읽기만 하는 인자는 const char *.
디버깅은 gcc -g -O0으로 빌드한 뒤 gdb에서 break my_strncat → display dst → display strlen(dst) → next를 반복하면 strlen(dst)가 변하는 순간이 그대로 보인다.
6. 파일명 생성 — snprintf + time + “wx”
요약
문제: base_YYYYMMDDhhmm[index] 이름을 중복 없이 만들어야 함
원인: fprintf 오용, 쓴 뒤 크기 검사, "rw" 모드, fp 스코프, TOCTOU
처방: snprintf로 쓰기 전 검사, fopen("wx")로 존재 확인과 생성을 원자적으로, errno != EEXIST면 즉시 종료
결과: 있으면 index를 올려 재시도, 그 외 오류는 바로 실패
상세 보기
base_name_YYYYMMDDhhmm[index] 형태의 이름을 만들고, 이미 있으면 index를 올려가며 새 파일을 생성하는 함수다.
int make_filename(const char *base_name, char *filename, size_t size) { char ts[16]; time_t now = time(NULL); strftime(ts, sizeof(ts), "%Y%m%d%H%M", localtime(&now)); int index = 0; FILE *fp = NULL; // do 블록 밖에서 선언 do { int n = snprintf(filename, size, "%s_%s[%d]", base_name, ts, index); if (n < 0 || (size_t)n >= size) { // 이름 만들 때 바로 크기 검사 printf("Error overflow name\n"); return -1; } fp = fopen(filename, "wx"); // 있으면 NULL, 없으면 생성 if (fp == NULL && errno != EEXIST) { perror("fopen"); // 권한/경로 문제는 재시도해도 소용없음 return -1; } index++; } while (fp == NULL); // 이미 존재(EEXIST)하면 index+1로 재시도 fclose(fp); return 0;}
처음 짠 코드에서 고친 점들이다.
fprintf → snprintf: fprintf는 FILE*에 쓰는 함수다. 문자열 버퍼에는 snprintf.
크기 검사는 쓰기 전에: 이름을 쓴 뒤에 검사하면 이미 오버플로가 난 뒤다. snprintf 반환값(잘리지 않았다면 쓰였을 길이)으로 검사한다.
"rw"는 유효한 모드가 아니다: 유효한 모드는 r w a r+ w+ a+ (+ b, x).
"wx"로 존재 확인과 생성을 한 번에: access()/stat()으로 확인한 뒤 fopen()하면 그 사이에 파일이 바뀔 수 있다(TOCTOU). "wx"(C11)는 이미 있으면 실패하고 없으면 원자적으로 생성한다.
errno != EEXIST면 즉시 종료: 권한 없음 같은 이유로 실패하는데도 무한히 도는 것을 막는다.
fp는 do 블록 밖에서 선언해야 while (fp == NULL)에서 보인다.
TIP
파일명에 [·]는 Windows에서는 허용되지만 쉘에서 글로빙 문자로 해석된다. 실무에서는 _%d 정도가 다루기 편하다.
💡 실습 환경 팁 — gdb 디버깅과 man 페이지
gdb를 쓰려면 gcc에 -g 옵션이 필요하다
상세 보기 (-g 옵션, gdb 명령)
-g 없이 빌드하면 실행 파일에 디버그 심볼(소스 줄 번호, 변수 이름) 이 들어가지 않아, gdb에서 break 함수명이나 print 변수를 해도 소스와 연결되지 않는다.
gcc -g -O0 -Wall -Wextra -o test test.cgdb ./test
옵션
의미
-g
디버그 심볼 포함 (gdb 사용에 필수)
-O0
최적화 끄기 — 변수가 최적화로 사라지거나 줄 순서가 바뀌는 것을 방지
-Wall -Wextra
경고 활성화
자주 쓰는 gdb 명령은 다음과 같다.
break main # 중단점 (break 파일:줄번호 도 가능)
run # 실행 (인자: run arg1 arg2)
next / step # 한 줄 실행 / 함수 안으로 진입
print dst # 변수 출력
display dst # 스텝마다 자동 출력
x/16xb dst # 메모리를 바이트 단위 16진수로 보기
bt # 호출 스택
vim에서 man 페이지 보기 — 숫자 + Shift+K
상세 보기 (K, 2K, 3K)
vim에서 단어 위에 커서를 두고 Shift+K 를 누르면 man <단어>가 열린다. 이때 앞에 숫자를 붙여 숫자 + Shift+K 를 누르면 그 숫자의 섹션 man 페이지가 열린다.
키
실행되는 명령
보이는 것
K
man stat
섹션 1 — 쉘 명령어
2K
man 2 stat
섹션 2 — 시스템 콜 (C 함수)
3K
man 3 fopen
섹션 3 — C 라이브러리 함수
그냥 K만 누르면 같은 이름의 쉘 명령어(stat, rename)가 먼저 나오므로, C 함수의 설명을 보려면 숫자를 붙인다.
📝 교육자료 추가 과제 (8-7) — 아직 구현하지 않음
요구사항과 확인할 점 (parse_kv, read_line)
교육자료 뒤쪽에 실었는데 이 노트의 실습에는 없던 과제다. 구현·검증은 아직 하지 않았고, 요구사항과 확인할 점만 정리해 둔다.
parse_kv — " log_level = debug "를 key="log_level", val="debug"로 나눈다.
int parse_kv(const char *line, char *key, size_t key_size, char *val, size_t val_size);// 0 성공 / -1 인자 오류(NULL, 크기 0) / -2 '=' 없음 / -3 key가 빈 문자열 / -4 key 버퍼 부족 / -5 val 버퍼 부족
요구
확인할 점
첫 번째 =로 분리
value 안에 =가 더 있어도 그대로 값의 일부
key·value 앞뒤 공백(space, tab) 제거
앞과 뒤를 따로 처리, 전부 공백이면 빈 문자열 → key면 -3
line을 수정하지 않는다 (리터럴이 올 수 있음)
strchr가 돌려준 포인터로 *p = '\0'을 쓰는 것도 수정이다 → 위치(인덱스)만 구하고 memcpy로 복사한 뒤 끝에 '\0' (8-04와 같은 문제)
버퍼에 다 안 들어가면 잘라 저장하지 말고 에러
길이가 size - 1이면 들어가고 size이면 -4/-5. 두 경계를 모두 시험
인자 오류 외의 에러에서는 key, val을 ""로
에러 경로마다 빠뜨리기 쉬우니 한 곳에서 초기화
read_line — getchar()로 한 줄을 읽는다.
int read_line(char *buf, size_t size);// 0 정상 / 1 줄이 잘림 / -1 읽은 문자 없이 EOF
개행은 저장하지 않고, 입력이 size - 1보다 길면 나머지는 읽어서 버리되(다음 줄에 섞이지 않게) 반환값 1로 알린다. buf는 어떤 경우에도 널 종료.
getchar()의 결과는 int로 받아 EOF와 비교한다 (§4의 8-05-b). 마지막 줄에 개행이 없어도 문자를 읽었다면 정상으로 처리해야 한다.
main은 EOF까지 한 줄씩 읽어 parse_kv에 넘기고, 잘린 줄은 분석하지 않고 에러로 출력한다. key 버퍼 16·value 버퍼 32로 두고 길이 15/16(key), 31/32(value)를 모두 시험한다.